<?php
namespace Pinenut;

use \Pinenut\Di\Injectable;

/**
 *
 *
 *
 * Класс с базовым интерфейсом для кеширования
 * Предполагается, что он должен наследоваться реализациями
 * для конкретного хранилища (APC, memcached)
 *
 * Ключевые моменты реализации:
 * 1) пакетные операции
 * 2) инвалидация по тэгам
 * 3) поддержка "грязных данных" как по времени жизни, так и по тэгам
 * 4) для "грязных" записей - проверка на блокировку (опционально)
 * 5) для ненайденных записей проверка на блокировку и установка в случае её отсутствия (опционально)
 * 6) локальная буферизация данных
 *
 * --Для реализации всех этих извращений придётся ввести объект кеширования,
 * --у которого, помимо собственно данных, будет идти еще набор служебных свойств
 * --таких как тэги, время жизни, признак "грязности"
 *
 * TODO: Хотелось бы реализовать так же стратегию прямого сброса кеша (по тем же тэгам)
 * эта схема нужна для оптимизации анонимной части сайта - кеширование анонимных запросов
 * на фронтэнде в memcached (модуль для nginx). Для этого придётся хранить списки ключей
 * для каждого тэга. Видимо будет отдельный абстрактный класс
 *
 * Примеры обращений:
 *
 * \ActionCache::set()
 * ->data('qwe', $data1, 100)
 * ->data('qwe1', 'wdasd', null, ['tag1', 'tag2'])
 * ->execute();
 *
 * \ActionCache::set('qwe', $data2, 100, ['tag2', 'tag4']);
 *
 * $array = \ActionCache::get()
 * ->key('qwe1')
 * ->key('qwe2')
 * ->key('qwe3')
 * ->withDirty()
 * ->withWait()
 * ->all();
 *
 * $array = \ActionCache::get(['qwe1', 'qwe2', 'qwe3'], true, true);
 *
 * $data1 = \ActionCache::get()
 * ->key('qwe1')
 * ->withDirty()
 * ->withWait()
 * ->one();
 *
 * $data1 = \ActionCache::get('qwe1', true, true);
 *
 * \ActionCache::lock()
 * ->key('qwe1')
 * ->execute();
 *
 * \ActionCache::lock('qwe1');
 *
 * $data1 = \ActionCache::get()
 * ->key('qwe1')
 * ->key('qwe2')
 * ->withDirty()
 * ->withWait()
 * ->withLock()
 * ->all();
 *
 *
 * $finder = \ActionCache::get()
 * ->key('qwe1')
 * ->key('qwe2')
 * ->withDirty()
 * ->withWait()
 * ->withLock();
 *
 * $array = $finder->all();
 *
 * $finder->getDirtyKeys();
 *
 * foreach ($array as $key->$val) {
 * $dirty = $finder->isDirty($key);
 * }
 *
 * $finder->getLockedKeys();
 * foreach ($array as $key->$val) {
 * $lock = $finder->wasLocked($key);
 * }
 *
 *
 *
 * \ActionCache::clean()
 * ->tag('qwe1')
 * ->tag('qwe2')
 * ->execute();
 *
 * \ActionCache::clean(['t1','t2']);
 *
 *
 * @param string $string        	
 * @return Cache
 *
 *
 *
 * @author kosh
 *        
 */
abstract class Cache {
	

	static $collection;
	static protected $_adapterClass;// = "\Pinenut\Cache\Adapter\Memcached";
	static protected  $_adapter = [];
	
	/**
	 * Префикс ключей
	 */
	static $dataPrefix = 'data_';
	static $tagPrefix = 'tag_';
	static $lockPrefix = 'lock_';
	
	/**
	 * Время жизни блокировки
	 * 
	 * @var int
	 */
	static $lockExpired = 5;
	
	/**
	 * Время жизни грязных данных
	 * @var int
	 */
	static $dirtyExpired = 3600; 
	
	
	/** 
	 * Время ожидания повтора 
	 * @var int в микросекундах
	 */
	static $waitTime = 100000;
	
	/**
	 * Типы действий
	 */
	const GET = 1;
	const SET = 2;
	const LOCK = 3;
	const CLEAN = 4;
	
	/**
	 * Действие
	 *
	 * @var enum
	 */
	protected $action = 1; // ждем 5.7 static::GET;
	
	/**
	 * Ключи
	 *
	 * @var string[]
	 */
	protected $keys = [ ];
	/**
	 * Тэги
	 *
	 * @var string[]
	 */
	protected $tags = [ ];
	
	/**
	 * Данные, время жизни, тэги
	 */
	protected $data = [ ];
	protected $dataExpired = [ ];
	protected $dataTags = [ ];
	
	/**
	 * Признаки необходимости сделать что-то дополнительно
	 * Возвращать глязные данные, ждать данных, лочить на перестроение чего нет
	 */
	protected $dirty = false;
	protected $wait = false;
	protected $lock = false;
	
	/**
	 * Списки ключей после выполнения запроса, соответствеющие
	 * определённым состояниям данных
	 */
	protected $dirtyKeys = [ ];
	protected $lockedKeys = [ ];
	
	
	/**
	 * Внедряем зависимости
	 */
	use Injectable;
	
	/**
	 * Получаем данные из кеш
	 * Если указаны ключи или ключ - значит осуществляем прямое обращение,
	 * иначе возвращаем объект, ответственный за работу
	 *
	 * @param string|string[] $keys
	 *        	ключ-ключи
	 * @param boolean $dirty
	 *        	возвращать грязные данные, если есть
	 * @param boolean $wait
	 *        	ждать, если отсутствуют, но перестраиваются
	 * @param boolean $lock
	 *        	лочить отсутствующие записи
	 * @return Cache|mixed|mixed[] 
	 */
	static public function get($keys = null, $dirty = false, $wait = false, $lock = false) {
		$that = new static ();
		$that->actionGet ( true );
		$that->dirty = $dirty;
		$that->wait = $wait;
		$that->lock = $lock;
		
		if (null === $keys) {
			return $that;
		} else {
			$that->keys = ( array ) $keys;
			if (is_array ( $keys )) {
				return $that->all ();
			} else {
				return $that->one ();
			}
		}
	}
	
	/**
	 * Стартуем действие по сохранению данных
	 *
	 * @param string $key        	
	 * @param mixed $data        	
	 * @param int $expired        	
	 * @param string|string[] $tags        	
	 * @return Cache
	 */
	static public function set($key = null, $data = null, $expired = null, $tags = null) {
		$that = new static ();
		$that->actionSet ( true );
		if (null === $key) {
			return $that;
		} else {
			$that->data [$key] = $data;
			$that->dataExpired [$key] = $expired;
			$that->dataTags [$key] = $tags ? ( array ) $tags : [];
			$that->execute ();
		}
	}
	
	/**
	 * Лочим указанные записи
	 *
	 * @param string $keys        	
	 */
	static public function lock($keys = null) {
		$that = new static ();
		$that->actionLock ( true );
		
		if (null === $keys) {
			return $that;
		} else {
			$that->keys = ( array ) $keys;
			$that->execute ();
		}
	}
	
	/**
	 * Инвалидируем по укаанным зависимостям
	 *
	 * @param string|string[] $tags        	
	 */
	static public function clean($tags = null) {
		$that = new static ();
		$that->actionClean ( true );
		
		if (null === $tags) {
			return $that;
		} else {
			$that->tags = ( array ) $tags;
			$that->execute ();
		}
	}
	
	/**
	 * Определяем действия получения данных
	 *
	 * @param string $value        	
	 * @return boolean
	 */
	function actionGet($value = null) {
		if (null !== $value) {
			$this->action = $value ? static::GET : null;
		}
		return $this->action === static::GET;
	}
	
	/**
	 * Определяем действия сохранения данных
	 *
	 * @param string $value        	
	 * @return boolean
	 */
	function actionSet($value = null) {
		if (null !== $value) {
			$this->action = $value ? static::SET : null;
		}
		return $this->action === static::SET;
	}
	
	/**
	 * Определяем действия инвалидации данных
	 *
	 * @param string $value        	
	 * @return boolean
	 */
	function actionClean($value = null) {
		if (null !== $value) {
			$this->action = $value ? static::CLEAN : null;
		}
		return $this->action === static::CLEAN;
	}
	
	/**
	 * Определяем действия блокировки данных
	 *
	 * @param string $value        	
	 * @return boolean
	 */
	function actionLock($value = null) {
		if (null !== $value) {
			$this->action = $value ? static::LOCK : null;
		}
		return $this->action === static::LOCK;
	}
	
	/**
	 * Признак того, что можно получать и "грязные" данные (инвалидированные)
	 *
	 * @param string $value        	
	 * @return Cache
	 */
	function withDirty($value = true) {
		$this->dirty = $value;
		return $this;
	}
	
	/**
	 * Признак того, что ожидаем формирования залоченных данных, если нужно
	 *
	 * @param string $value        	
	 * @return Cache
	 */
	function withWait($value = true) {
		$this->wait = $value;
		return $this;
	}
	
	/**
	 * Признак того, что нужно залочить не найденные данные на перестроение
	 *
	 * @param string $value        	
	 * @return Cache
	 */
	function withLock($value = true) {
		$this->lock = $value;
		return $this;
	}
	
	/**
	 * Добавляем еще один ключ к выборке
	 *
	 * @param string|string[] $key        	
	 * @return Cache
	 */
	function key($key) {
		if (is_string($key)) {
			$this->keys [] = $key;
		} elseif (is_array($key)) {
			$this->keys = array_merge($this->keys, $key);
		}
		return $this;
	}
	
	/**
	 * Добавляем еще один тэг к выборке
	 *
	 * @param string $tag        	
	 * @return Cache
	 */
	function tag($tag) {
		$this->tags [] = $tag;
		return $this;
	}
	
	/**
	 * Добавляем данные
	 *
	 * @param string $key        	
	 * @param mixed $data        	
	 * @param int $expired        	
	 * @param string|string[] $tags        	
	 * @return Cache
	 */
	function data($key, $data, $expired = null, $tags = null) {
		$this->data [$key] = $data;
		$this->dataExpired [$key] = $expired;
		$this->dataTags [$key] = $tags ? ( array ) $tags : [];
		return $this;
	}
	
	/**
	 * Получаем данные в виде массива
	 *
	 * 1) получаем основные данные и локи для всех записей
	 * 2) выделяем тэги, запрашиваем их
	 * 3) определяем в результате чистые, грязные и залоченные данные
	 * 4) если возвращаем только чистые данные, не лочим - возвращаем их
	 * 5) если возвращаем чистые и грязные данные, не лочим - возвращаем их
	 * 6) если есть лоченые данные и указано что ждём - ждём их
	 * 7) если не всё нашли и указано что нужно ззалочить - лочим их и
	 * возвращаем не все данные
	 * 
	 * 
	 * блокируем если не нашли чистых и нет блокировки
	 * 
	 */
	function all() {
		
		if (static::GET != $this->action) {
			return [ ];
		}
		
//		\Log::begin(null, 'Cache');

		$adapter = $this->getAdapter ();
		
		$this->lockedKeys = [];		
		$result = [];
		$tagsData = [];		
		
		$keys = $this->keys;

		do {
			
			$waitKeys = [];
			$tagKeys = [];
		
			$getKeys = array_merge(static::prefixForData ( $keys), static::prefixForLocks($keys));
			
			// получаем и данные и блокировки
			$data = $adapter->get ( $getKeys );
			// $locks = static::prefixForLocks($this->keys);
			
			// сейчас мы можем определить не найденные данные (если нужно обождать
			// - запрашиваем для них блокировку), протухшие по времени данные
			// (если используем грязные данные)
			
			foreach ( $keys as $key ) {
				$dataKey = static::prefixForData($key);
				$lockKey = static::prefixForLocks($key);
				
				if (isset ( $data [$dataKey] )) {
					// найдены какие-то данные
					if ($data [$dataKey]['expired'] > 0 
						&& $data [$dataKey]['expired'] < time()) {
						// протухли
						$this->dirtyKeys[] = $key;
						
						if ($this->dirty) {
							// добавляем в результат, если нужно
							$result[$key] = $data [$dataKey]['data'];
							//$this->dirtyKeys[] = $key;
							
						} elseif ($this->wait && isset($data[$lockKey])) {
							// только чистые данне и есть блокировка - если нужно подождать - ждем
							// добавляем в список ожидания разблокирования, если заблокирована и нужно ждать
							$waitKeys[] = $key;
						}
						
						// протухший и не заблокированный - блокируем
						if ($this->lock && !isset($data[$lockKey])) {
							//echo "locked+1 - $lockKey\n";
							$this->lockedKeys[] = $key;
						}
						
					} else {
						// не протухли по времени
						if (count($data [$dataKey]['tags'])) {
							// надо валидировать по тэгам, добавляем ключ в соответствующий список
							$tagKeys[] = $key; 
							// собираем тэги для получения версий, если их не загружено ранее
							foreach ($data[$dataKey]['tags'] as $tag=>$exp) {
								if (!isset($tagsData[$tag])) {
									$tags[] = $tag;
								}
							}
						} else {
							// если не требуется тэгов - добавляем в результат, т.к. запись валидна
							$result[$key] = $data [$dataKey]['data'];
						}
					}
					
				} else {
					// данные не надены,
					// если включено ожидание отмены блокировки - добавляем 
					// в список запрашиваемых блокировок
					if ($this->wait && isset($data[$lockKey])) {
						$waitKeys[] = $key;
					}
					
					// протухший и не заблокированный - блокируем
					if ($this->lock && !isset($data[$lockKey])) {
						//echo "locked+2 - $lockKey\n";
						$this->lockedKeys[] = $key;
					}
				}
			}

			// если насобирали записи, по которым нужно инвалидировать тэги - запрашиваем тэги
			if (count($tagKeys)) {
//				\Log::error('action-cache-get-tags', $tags);
				$tagsData = array_merge($adapter->get($tags));
				foreach($tagKeys as $key) {
					$dataKey = static::prefixForData($key);
					$lockKey = static::prefixForLocks($key);
						
					// для каждой записи проверяем тэги на валидность
					$valid = true;
					foreach ($data[$dataKey]['tags'] as $tag => $version) {
//						\Log::error('compare tag - ' . $tag, [@$tagsData[$tag], $version]);
						if (!isset($tagsData[$tag]) || $tagsData[$tag] > $version) {
							$valid = false;
							\Log::error('invalid tag', $tag);
							break;
						}
					}
//					\Log::error('valid', $valid);
					if (!$valid) {
						
//						\Log::error('invalid', $key);
						// протухли
						$this->dirtyKeys[] = $key;
						
						if ($this->dirty) {
							// добавляем в результат, если нужно
							$result[$key] = $data [$dataKey]['data'];
							//$this->dirtyKeys[] = $key;
								
						} elseif ($this->wait) {
							// нужны только чистые данне и есть блокировка - если нужно подождать - ждем
							if (isset($data[$lockKey])) {
								// добавляем в список ожидания разблокирования, если заблокирована и нужно ждать
								$waitKeys[] = $key;
							}
						}
						
						// протухший и не заблокированный - блокируем
						if ($this->lock && !isset($data[$lockKey])) {
							//echo "locked+3 - $lockKey\n";
							$this->lockedKeys[] = $key;
						}
						
					} else {
						// если не требуется тэгов - добавляем в результат, т.к. запись валидна
						$result[$key] = $data [$dataKey]['data'];
					}
					
				}
			}
			
			$keys = $waitKeys;
			if (count($keys)) {
				usleep(static::$waitTime);
			}
			
		} while (count($keys));
		
		// блокируем
		if (count($this->lockedKeys)) {
			$this->lock($this->lockedKeys);
		}

		
// 		\Log::end('Cache get from ' . static::$collection,
// 			array(
// 				'found' => count($result),
// 				'keys' => $this->keys,
// 			));
		
		return $result;
	}
	
	/**
	 * Получаем данные одного ключа
	 */
	function one() {
		$this->keys = ( array ) array_shift ( $this->keys );
		return array_shift ( $this->all () );
	}
	/**
	 * Добавляем префикс к основным ключам
	 * 
	 * @param array $keys        	
	 * @return multitype: string
	 */
	static function prefixForData($keys) {
		$prefix = static::$dataPrefix;
		if (is_array ( $keys )) {
			return array_map ( function ($key) use($prefix) {
				return $prefix . $key;
			}, $keys );
		} else {
			return $prefix . $keys;
		}
	}
	
	/**
	 * Добавляем префикс к именам тэгов
	 * 
	 * @param array $tags        	
	 * @return multitype: string
	 */
	static function prefixForTags($keys) {
		$prefix = static::$tagPrefix;
		if (is_array ( $keys )) {
			return array_map ( function ($tag) use($prefix) {
				return $prefix . $tag;
			}, $keys );
		} else {
			return $prefix . $keys;
		}
	}
	
	/**
	 * Добавляем префикс блокировки для ключей
	 * 
	 * @param array $locks        	
	 * @return multitype: string
	 */
	static function prefixForLocks($keys) {
		$prefix = static::$lockPrefix;
		if (is_array ( $keys )) {
			return array_map ( function ($lock) use($prefix) {
				return $prefix . $lock;
			}, $keys );
		} else {
			return $prefix . $keys;
		}
	}
	
	/**
	 * Выполняем одно из указанных действий
	 * 
	 * @throws \Exception
	 */
	function execute() {
		
//		\Log::begin(null, 'Cache');
		$adapter = $this->getAdapter ();
		switch ($this->action) {
			case static::SET :
				// собираем тэги и получаем их значения
				$allTags = [ ];
				foreach ( $this->dataTags as &$array ) {
					// добавляем префикс
					$array = static::prefixForTags ( (array)$array );
					$allTags = array_merge ( $allTags, array_values($array) );
				}

//				\Log::error('tags3!', $allTags);
//				\Log::error('tagsdata',  $this->dataTags);
				$tagsValues = $adapter->get ( array_unique ( $allTags ) );
//				\Log::error('tags-v!', $tagsValues);
				
				$data = [ ];
				$newTags = [ ];
				// группируем данные
				foreach ( $this->data as $key => $value ) {
					$expired = isset ( $this->dataExpired [$key] ) ? intval ( $this->dataExpired [$key] ) : 0;
					// если меньше месяца - плюсуем к тайму
					if ($expired > 0 && $expired < 31*24*3600) $expired += time();					
					
					// по всем требуемым тэгам ищем их версии, если не находим - добавляем
					$tags = isset ( $this->dataTags [$key] ) ? $this->dataTags [$key] : [ ];
//					\Log::error('tags+', $tags);
					$values = [ ];
					foreach ( $tags as $tag ) {
						//$prefixTag = static::prefixForTags($tag);
						if (isset ( $tagsValues [$tag] )) {
							$values [$tag] = $tagsValues [$tag];
						} elseif (isset ( $newTags [$tag] )) {
							$values [$tag] = $newTags [$tag];
						} else {
							// добавляем
							$values [$tag] = $newTags [$tag] = time ();
						}
					}
					
					$prefixKey = static::prefixForData($key);
//					\Log::error('vvv+', $values);
//					\Log::error('vvv+', ['key' => $prefixKey, 'tags' => $values, 'exp' => $expired]);
						
					$data [$expired] [$prefixKey] = [ 
							'data' => $value,
							'expired' => $expired,
							'tags' => $values 
					];
				}
				// сохраняем данные (группируем их по времени протухания),
				// сохраняем новые тэги
				foreach ( $newTags as $tag => $value ) {
					// тэги соохраняем с нулевым протуханием - т.е. вечные
					$data [0] [$tag] = $value;
				}

//				\Log::error('data+', $data);
				
				foreach ( $data as $expired => $group ) {
					$adapter->set ( $group, null, $expired + (($expired > 0)?static::$dirtyExpired:0) );
				}
				
				// грохаем блокировки
				// ЗЫ: а собственно - нафига, сами протухнут
				$adapter->delete(static::prefixForLocks(array_keys($this->data)));
				
				break;
			
			case static::CLEAN :
				// для всех указанных тэгов сохраняем новую версию
				$tags = static::prefixForTags ( $this->tags );
				$data = [ ];
				$version = time ();
				foreach ( $tags as $tag ) {
					$data [$tag] = $version;
				}
				$adapter->set ( $data );
				
				break;
			
			case static::LOCK :
				$locks = static::prefixForLocks ( $this->keys );
				$locks = array_fill_keys ( array_values ( $locks ), 1 );

				$adapter->set ( $locks, null, static::$lockExpired + time () );
				
				break;
			
			default :
				throw new \Exception ( 'Unknown cache action', 503 );
				break;
		}
// 		switch ($this->action) {
// 			case static::GET:
// 				break;
			
// 			case static::SET:
// 				\Log::end('Cache set to ' . static::$collection, 
// 					array(
// 						'keys' => array_keys($this->data)
// 					));
// 				break;
//  			case static::LOCK:
// 				\Log::error('Cache lock in ' . static::$collection, 
// 					array(
// 						'keys' => $locks
// 					));
// 				break;
// 			case static::CLEAN:
// 				\Log::end('Cache clean in ' . static::$collection, 
// 					array(
// 						'tags' => $this->tags
// 					));
// 				break;
// 		}
	}
	
	/**
	 * Возвращаем список грязных ключей
	 * @return multitype:
	 */
	function getDirtyKeys() {
		return $this->dirtyKeys;
	}
	
	/**
	 * Проверка - грязный ли ключ
	 * @param string $key
	 * @return boolean
	 */
	function isDirty($key) {
		return in_array($key, $this->dirtyKeys);
	}
	
	/**
	 * Возвращаем список залоченных по просьбе ключей
	 * @return multitype:
	 */
	function getLockedKeys() {
		return $this->lockedKeys;
	}
	
	/**
	 * Проверка - залочен ли по просьбе ключ
	 * @param string $key
	 * @return boolean
	 */
	function wasLocked($key) {
		return in_array($key, $this->lockedKeys);
	}
	
	
	/**
	 *
	 * @return Pinenut\Cache\Adapter
	 */
	function getAdapter() {
		$class = get_called_class();
		if (!isset(static::$_adapter[$class])) {
			static::$_adapter[$class] = new static::$_adapterClass ( static::$collection );
		}
		return static::$_adapter[$class];
	}
	
	//TODO: выпилить
	static function getAllKeys () {
		$c = new static();
		return $c->getAdapter()->getAllKeys();
	}
	
}